Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 64a58575e53beee8c6a5b9a2763575b8f6d7253d


Parents : 350b95c
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-17T19:57:05-05:00

code cleanup, ui improvements, hardening

Changes

29 files changed, 849 insertions(+), 99 deletions(-)


Diff

diff --git a/docs/en/installation.md b/docs/en/installation.md
index 408a5929..cfe7cff3 100644
--- a/docs/en/installation.md
+++ b/docs/en/installation.md
@@ -35,6 +35,7 @@ Quick start with Compose:
```bash
docker compose up -d
```
+
Manual run with a named volume for persistence:
```bash

diff --git a/meshchatx.rsm b/meshchatx.rsm
index 0deb2bf4..a4f0e602 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ

diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 172bcab2..534b5cc4 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -10666,11 +10666,11 @@ class ReticulumMeshChat:
# get path params
identity_hash_hex = request.match_info.get("identity_hash", "")
- try:
- timeout_seconds = int(request.query.get("timeout", 15))
- except (TypeError, ValueError):
- timeout_seconds = 15
- timeout_seconds = max(1, min(timeout_seconds, 120))
+ from meshchatx.src.backend.call_timeout import clamp_call_timeout_seconds
+
+ timeout_seconds = clamp_call_timeout_seconds(
+ request.query.get("timeout", 15),
+ )
try:
# convert hash to bytes

diff --git a/meshchatx/src/backend/call_timeout.py b/meshchatx/src/backend/call_timeout.py
new file mode 100644
index 00000000..6cd828bc
--- /dev/null
+++ b/meshchatx/src/backend/call_timeout.py
@@ -0,0 +1,26 @@
+# SPDX-License-Identifier: 0BSD
+"""Clamp helpers for telephone call initiation timeouts."""
+
+from __future__ import annotations
+
+DEFAULT_CALL_TIMEOUT_SECONDS = 15
+MIN_CALL_TIMEOUT_SECONDS = 1
+MAX_CALL_TIMEOUT_SECONDS = 120
+
+
+def clamp_call_timeout_seconds(
+ raw,
+ *,
+ default: int = DEFAULT_CALL_TIMEOUT_SECONDS,
+ min_s: int = MIN_CALL_TIMEOUT_SECONDS,
+ max_s: int = MAX_CALL_TIMEOUT_SECONDS,
+) -> int:
+ """Parse and clamp a call timeout query value.
+
+ Invalid values fall back to default. Result is always within [min_s, max_s].
+ """
+ try:
+ timeout_seconds = int(raw) if raw is not None else int(default)
+ except (TypeError, ValueError):
+ timeout_seconds = int(default)
+ return max(int(min_s), min(timeout_seconds, int(max_s)))

diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 0d2441c0..1be4a5c0 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -41,14 +41,14 @@
<template v-else>
<div
- class="sticky top-0 z-100 flex bg-white dark:bg-zinc-950 border-gray-200 dark:border-zinc-800 border-b min-h-16 shadow-xs transition-colors"
+ class="z-100 flex shrink-0 bg-white dark:bg-zinc-950 border-gray-200 dark:border-zinc-800 border-b min-h-16 shadow-xs transition-colors pt-[env(safe-area-inset-top,0px)]"
>
<div
class="flex w-full min-h-16 items-center gap-0 overflow-x-auto no-scrollbar pl-2 pr-2 sm:ps-0 sm:pe-4"
>
<button
type="button"
- class="sm:hidden shrink-0 mr-2 text-gray-500 hover:text-gray-600 dark:text-gray-400 dark:hover:text-gray-300"
+ class="sm:hidden shrink-0 mr-2 inline-flex min-h-[44px] min-w-[44px] items-center justify-center text-gray-500 hover:text-gray-600 dark:text-gray-400 dark:hover:text-gray-300"
@click="isSidebarOpen = !isSidebarOpen"
>
<MaterialDesignIcon :icon-name="isSidebarOpen ? 'close' : 'menu'" class="size-6" />
@@ -93,7 +93,7 @@
<LanguageSelector class="hidden sm:block" @language-change="onLanguageChange" />
<button
type="button"
- class="sm:hidden rounded-full p-1.5 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors relative"
+ class="sm:hidden rounded-full p-2 min-h-[44px] min-w-[44px] inline-flex items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors relative"
:title="$t('app.messages')"
@click="$router.push({ name: 'messages' })"
>
@@ -108,7 +108,7 @@
<button
v-if="rrcEnabled"
type="button"
- class="relative sm:hidden rounded-full p-1.5 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
+ class="relative sm:hidden rounded-full p-2 min-h-[44px] min-w-[44px] inline-flex items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
:title="$t('app.relay_chat')"
@click="$router.push({ name: 'relay-chat' })"
>
@@ -122,7 +122,7 @@
</button>
<button
type="button"
- class="relative rounded-full p-1.5 sm:p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
+ class="relative rounded-full p-2 min-h-[44px] min-w-[44px] inline-flex items-center justify-center sm:p-2 sm:min-h-0 sm:min-w-0 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
:title="$t('app.audio_calls')"
@click="$router.push({ name: 'call' })"
>
@@ -136,7 +136,7 @@
</button>
<button
type="button"
- class="sm:hidden rounded-full p-1.5 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
+ class="sm:hidden rounded-full p-2 min-h-[44px] min-w-[44px] inline-flex items-center justify-center text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
:title="isSyncingPropagationNode ? $t('app.syncing') : $t('app.sync_messages')"
@click="syncPropagationNode"
>
@@ -179,26 +179,26 @@
<!-- middle -->
<div
ref="middle"
- class="flex flex-1 w-full overflow-hidden transition-colors"
+ class="relative flex flex-1 w-full overflow-hidden transition-colors"
:style="shellCanvasStyle"
>
<!-- sidebar backdrop for mobile -->
<div
v-if="isSidebarOpen"
- class="fixed inset-0 z-65 bg-black/20 backdrop-blur-xs sm:hidden"
+ class="absolute inset-0 z-65 bg-black/20 backdrop-blur-xs sm:hidden"
@click="isSidebarOpen = false"
></div>
<!-- sidebar -->
<div
- class="fixed inset-y-0 left-0 z-70 transform transition-all duration-300 ease-in-out sm:relative sm:z-0 sm:flex sm:translate-x-0"
+ class="absolute inset-y-0 left-0 z-70 transform transition-all duration-300 ease-in-out sm:relative sm:inset-auto sm:z-0 sm:flex sm:translate-x-0"
:class="[
isSidebarOpen ? 'translate-x-0' : '-translate-x-full',
isSidebarCollapsed ? 'w-16' : 'w-80 md:max-lg:w-64 lg:w-80',
]"
>
<div
- class="flex h-full w-full flex-col overflow-y-auto border-r border-gray-200 bg-white dark:border-zinc-800 dark:bg-zinc-950 pt-16 sm:pt-0"
+ class="flex h-full w-full flex-col overflow-y-auto border-r border-gray-200 bg-white dark:border-zinc-800 dark:bg-zinc-950"
>
<!-- toggle button for desktop (h-10 aligns with Messages/Nomad collapse rows) -->
<div

diff --git a/meshchatx/src/frontend/components/CommandPalette.vue b/meshchatx/src/frontend/components/CommandPalette.vue
index 1286c7b5..1828c344 100644
--- a/meshchatx/src/frontend/components/CommandPalette.vue
+++ b/meshchatx/src/frontend/components/CommandPalette.vue
@@ -2,10 +2,13 @@
<template>
<transition name="slide-down">
- <div v-if="isOpen" class="fixed inset-x-0 top-0 z-200 flex items-start justify-center p-4 pointer-events-none">
+ <div
+ v-if="isOpen"
+ class="fixed inset-x-0 top-0 z-200 flex items-start justify-center p-4 pt-[max(0.5rem,env(safe-area-inset-top))] pointer-events-none"
+ >
<div
v-click-outside="close"
- class="w-full max-w-2xl bg-white/95 dark:bg-zinc-900/95 backdrop-blur-md rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-800 overflow-hidden flex flex-col max-h-[70vh] pointer-events-auto mt-2 sm:mt-8"
+ class="w-full max-w-2xl bg-white/95 dark:bg-zinc-900/95 backdrop-blur-md rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-800 overflow-hidden flex flex-col max-h-[min(70dvh,70vh)] pointer-events-auto mt-2 sm:mt-8"
>
<!-- search input -->
<div class="relative flex items-center p-4 border-b border-gray-100 dark:border-zinc-800">

diff --git a/meshchatx/src/frontend/components/Toast.vue b/meshchatx/src/frontend/components/Toast.vue
index b3f7d189..5cb6cfff 100644
--- a/meshchatx/src/frontend/components/Toast.vue
+++ b/meshchatx/src/frontend/components/Toast.vue
@@ -2,7 +2,7 @@
<template>
<div
- class="fixed max-sm:bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"
+ class="fixed max-sm:bottom-[calc(5.5rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"
>
<TransitionGroup name="toast">
<div
@@ -49,7 +49,9 @@
<!-- close button -->
<button
- class="ml-auto text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300"
+ type="button"
+ class="ml-auto inline-flex min-h-[44px] min-w-[44px] items-center justify-center -my-2 -mr-2 text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300"
+ :aria-label="$t('common.close')"
@click="remove(toast.id)"
>
<MaterialDesignIcon icon-name="close" class="h-4 w-4" />

diff --git a/meshchatx/src/frontend/components/call/CallOverlay.vue b/meshchatx/src/frontend/components/call/CallOverlay.vue
index b82df1fa..ccb12b46 100644
--- a/meshchatx/src/frontend/components/call/CallOverlay.vue
+++ b/meshchatx/src/frontend/components/call/CallOverlay.vue
@@ -3,7 +3,7 @@
<template>
<div
v-if="activeCall || initiationStatus || isEnded || wasDeclined"
- class="fixed bottom-4 right-4 z-100 w-80 bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-800 overflow-hidden transition-all duration-300"
+ class="fixed z-90 w-[min(20rem,calc(100%-1.5rem))] max-sm:left-1/2 max-sm:-translate-x-1/2 max-sm:right-auto sm:right-4 bottom-[max(1rem,env(safe-area-inset-bottom,0px))] max-sm:bottom-[calc(5.5rem+env(safe-area-inset-bottom,0px))] bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-800 overflow-hidden transition-all duration-300"
:class="{ 'ring-2 ring-red-500 ring-opacity-50': isEnded || wasDeclined }"
>
<!-- Header -->

diff --git a/meshchatx/src/frontend/components/messages/AddAudioButton.vue b/meshchatx/src/frontend/components/messages/AddAudioButton.vue
index fa3890e9..4336f698 100644
--- a/meshchatx/src/frontend/components/messages/AddAudioButton.vue
+++ b/meshchatx/src/frontend/components/messages/AddAudioButton.vue
@@ -17,7 +17,7 @@
<button
v-else
type="button"
- class="my-auto inline-flex items-center justify-center rounded-lg p-1.5 text-gray-500 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800 hover:text-gray-800 dark:hover:text-zinc-100 transition-colors"
+ class="my-auto inline-flex items-center justify-center rounded-lg min-h-[44px] min-w-[44px] p-1.5 text-gray-500 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800 hover:text-gray-800 dark:hover:text-zinc-100 transition-colors"
@click="showMenu"
>
<MaterialDesignIcon icon-name="microphone-plus" class="w-5 h-5" />

diff --git a/meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue b/meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue
index 9c70b560..920732f1 100644
--- a/meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationPeerHeader.vue
@@ -29,7 +29,7 @@
<MaterialDesignIcon icon-name="tag-outline" class="size-4" />
</div>
<div
- class="font-semibold text-gray-900 dark:text-zinc-100 truncate max-w-[120px] sm:max-w-sm text-base"
+ class="font-semibold text-gray-900 dark:text-zinc-100 truncate max-w-[min(40vw,12rem)] sm:max-w-sm text-base"
:title="selectedPeer.custom_display_name ?? selectedPeer.display_name"
>
{{ selectedPeer.custom_display_name ?? selectedPeer.display_name }}
@@ -37,7 +37,7 @@
</div>
<div class="text-xs text-gray-500 dark:text-zinc-400 mt-0.5 flex items-center gap-2 min-w-0">
<div
- class="cursor-pointer hover:text-blue-500 transition-colors truncate max-w-[120px] sm:max-w-none shrink-0"
+ class="cursor-pointer hover:text-blue-500 transition-colors truncate max-w-[min(40vw,12rem)] sm:max-w-none shrink-0"
:title="selectedPeer.destination_hash"
@click="$emit('copy-hash', selectedPeer.destination_hash)"
>

diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 092c6841..324e0418 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -61,10 +61,12 @@
<!-- Share Contact Modal -->
<div
v-if="isShareContactModalOpen"
- class="fixed inset-0 z-100 flex items-center justify-center p-4 bg-black/50 backdrop-blur-xs"
+ class="fixed inset-0 z-100 flex items-center justify-center p-4 pt-[max(0.75rem,env(safe-area-inset-top))] pb-[max(0.75rem,env(safe-area-inset-bottom))] bg-black/50 backdrop-blur-xs"
@click.self="isShareContactModalOpen = false"
>
- <div class="w-full max-w-md bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden">
+ <div
+ class="w-full max-w-md bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden max-h-[min(90dvh,40rem)] flex flex-col"
+ >
<div class="px-6 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between">
<h3 class="text-lg font-bold text-gray-900 dark:text-white">
{{ $t("messages.share_contact_modal_title") }}
@@ -149,27 +151,33 @@
<!-- stranger trust banner -->
<div
v-if="isStrangerPeer && !strangerBannerDismissed && showUnknownContactBanner"
- class="mx-3 mt-2 mb-0 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700 rounded-lg flex items-center gap-3 text-sm"
+ class="mx-3 mt-2 mb-0 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700 rounded-lg flex flex-col gap-3 text-sm sm:flex-row sm:items-center"
>
- <MaterialDesignIcon
- icon-name="alert-circle-outline"
- class="w-5 h-5 text-amber-600 dark:text-amber-400 shrink-0"
- />
- <span class="flex-1 text-amber-900 dark:text-amber-200">
- {{ $t("messages.stranger_banner_text") }}
- </span>
- <button
- class="px-3 py-1 text-xs font-medium rounded-md bg-amber-600 hover:bg-amber-700 text-white transition-colors"
- @click="addStrangerAsContact"
- >
- {{ $t("messages.add_to_contacts") }}
- </button>
- <button
- class="px-2 py-1 text-xs text-amber-600 dark:text-amber-400 hover:text-amber-800 dark:hover:text-amber-200 transition-colors"
- @click="strangerBannerDismissed = true"
- >
- {{ $t("messages.dismiss") }}
- </button>
+ <div class="flex items-start gap-3 min-w-0 flex-1">
+ <MaterialDesignIcon
+ icon-name="alert-circle-outline"
+ class="w-5 h-5 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5"
+ />
+ <span class="flex-1 text-amber-900 dark:text-amber-200">
+ {{ $t("messages.stranger_banner_text") }}
+ </span>
+ </div>
+ <div class="flex items-center gap-2 shrink-0 sm:justify-end">
+ <button
+ type="button"
+ class="min-h-[44px] flex-1 sm:flex-none px-3 py-2 text-xs font-medium rounded-md bg-amber-600 hover:bg-amber-700 text-white transition-colors"
+ @click="addStrangerAsContact"
+ >
+ {{ $t("messages.add_to_contacts") }}
+ </button>
+ <button
+ type="button"
+ class="min-h-[44px] px-3 py-2 text-xs text-amber-600 dark:text-amber-400 hover:text-amber-800 dark:hover:text-amber-200 transition-colors"
+ @click="strangerBannerDismissed = true"
+ >
+ {{ $t("messages.dismiss") }}
+ </button>
+ </div>
</div>
<!-- chat items -->
@@ -177,7 +185,7 @@
<div
id="messages"
ref="messagesScroll"
- class="flex-1 min-h-0 overflow-y-scroll bg-white dark:bg-zinc-950"
+ class="flex-1 min-h-0 overflow-y-auto bg-white dark:bg-zinc-950"
style="overflow-anchor: none; overscroll-behavior-y: contain"
:data-message-list-mode="useVirtualMessageList ? 'virtual' : 'reverse'"
:aria-busy="!messagesViewportReady ? 'true' : undefined"
@@ -270,7 +278,7 @@
>
<button
type="button"
- class="flex items-center justify-center size-8 rounded-full bg-white/90 dark:bg-zinc-800/90 backdrop-blur-sm border border-gray-200 dark:border-zinc-700 shadow-sm text-gray-500 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-700 hover:text-gray-700 dark:hover:text-zinc-200 transition-colors"
+ class="flex items-center justify-center size-10 min-h-[44px] min-w-[44px] rounded-full bg-white/90 dark:bg-zinc-800/90 backdrop-blur-sm border border-gray-200 dark:border-zinc-700 shadow-sm text-gray-500 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-700 hover:text-gray-700 dark:hover:text-zinc-200 transition-colors"
title="Scroll to bottom"
@click="scrollMessagesToBottom()"
>
@@ -314,7 +322,8 @@
<!-- send message -->
<div
- class="w-full border-t border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 px-3 sm:px-4 py-2.5"
+ class="w-full border-t border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 px-3 sm:px-4 pt-2.5"
+ :style="composerChromeStyle"
>
<div class="w-full">
<!-- banished user notification -->
@@ -458,10 +467,14 @@
</button>
<button
type="button"
- class="absolute top-1.5 right-1.5 inline-flex items-center justify-center w-6 h-6 rounded-full bg-black/55 text-white hover:bg-black/70 shadow-md"
+ class="absolute top-1 right-1 inline-flex items-center justify-center min-h-[44px] min-w-[44px] w-11 h-11 rounded-full text-white"
@click.stop="removeImageAttachment(slot - 1)"
>
- <MaterialDesignIcon icon-name="close" class="w-3.5 h-3.5" />
+ <span
+ class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-black/55 hover:bg-black/70 shadow-md"
+ >
+ <MaterialDesignIcon icon-name="close" class="w-3.5 h-3.5" />
+ </span>
</button>
</div>
</div>
@@ -546,7 +559,7 @@
</AddAudioButton>
<button
type="button"
- class="inline-flex items-center justify-center rounded-lg p-1.5 text-gray-500 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800 hover:text-gray-800 dark:hover:text-zinc-100 transition-colors"
+ class="inline-flex items-center justify-center rounded-lg min-h-[44px] min-w-[44px] p-1.5 text-gray-500 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800 hover:text-gray-800 dark:hover:text-zinc-100 transition-colors"
:title="$t('stickers.picker_tooltip')"
@click.stop="toggleStickerPicker"
>
@@ -1303,7 +1316,7 @@
v-if="imageModalUrl"
ref="imageModalOverlay"
tabindex="0"
- class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 dark:bg-black/90 backdrop-blur-xs p-4 outline-hidden"
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 dark:bg-black/90 backdrop-blur-xs p-4 pt-[max(1rem,env(safe-area-inset-top))] pb-[max(1rem,env(safe-area-inset-bottom))] outline-hidden"
@click="closeImageModal"
@keydown.left.prevent="imageModalNavigate(-1)"
@keydown.right.prevent="imageModalNavigate(1)"
@@ -1482,11 +1495,11 @@
>
<div
v-if="isRawMessageModalOpen"
- class="fixed inset-0 z-150 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs"
+ class="fixed inset-0 z-150 flex items-center justify-center p-4 pt-[max(0.75rem,env(safe-area-inset-top))] pb-[max(0.75rem,env(safe-area-inset-bottom))] bg-black/60 backdrop-blur-xs"
@click.self="isRawMessageModalOpen = false"
>
<div
- class="w-full max-w-2xl bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]"
+ class="w-full max-w-2xl bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[min(90dvh,48rem)]"
>
<div
class="px-6 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between shrink-0"
@@ -1762,6 +1775,7 @@ import {
MAX_CODEC2_DECODED_RAW_BYTES,
MAX_CODEC2_ENCODED_BYTES,
MAX_CODEC2_WAV_BYTES,
+ MAX_OPUS_ATTACHMENT_BYTES,
assertByteLengthAtMost,
} from "../../js/codec2DecodeLimits.js";
import { buildTimestampGroupedOldestFirst } from "../../js/messageTimestampGrouping.js";
@@ -1999,6 +2013,7 @@ export default {
sendStatusUiMs: Date.now(),
sendStatusTickInterval: null,
windowWidth: typeof window !== "undefined" ? window.innerWidth : 1024,
+ keyboardInsetPx: 0,
peerHeaderCompact: false,
peerHeaderResizeObserver: null,
scrollBottomGen: 0,
@@ -2157,6 +2172,15 @@ export default {
}
return this.$t("messages.compose_hint_automatic");
},
+ composerChromeStyle() {
+ const bottomPad = this.keyboardInsetPx > 0 ? Math.max(10, this.keyboardInsetPx) : null;
+ if (bottomPad != null) {
+ return { paddingBottom: `${bottomPad}px` };
+ }
+ return {
+ paddingBottom: "max(0.625rem, env(safe-area-inset-bottom, 0px))",
+ };
+ },
isSyncingPropagationNode() {
// Mirror App chrome: only spin for user-started sync, not auto-sync.
return false;
@@ -2512,6 +2536,7 @@ export default {
this._onWindowResize = () => {
this.windowWidth = window.innerWidth;
+ this.updateKeyboardInset();
};
window.addEventListener("resize", this._onWindowResize);
this._visualViewport = typeof window !== "undefined" ? window.visualViewport : null;
@@ -2519,6 +2544,7 @@ export default {
this._visualViewport.addEventListener("resize", this._onWindowResize);
this._visualViewport.addEventListener("scroll", this._onWindowResize);
}
+ this.updateKeyboardInset();
},
beforeUnmount() {
this.scrollBottomGen += 1;
@@ -2555,6 +2581,15 @@ export default {
this.clearAudioAttachmentCache();
},
methods: {
+ updateKeyboardInset() {
+ const vv = this._visualViewport;
+ if (!vv || typeof window === "undefined") {
+ this.keyboardInsetPx = 0;
+ return;
+ }
+ const inset = Math.round(Math.max(0, window.innerHeight - vv.height - vv.offsetTop));
+ this.keyboardInsetPx = inset > 48 ? inset : 0;
+ },
clearAudioAttachmentCache() {
const cache = this.lxmfMessageAudioAttachmentCache || {};
for (const url of Object.values(cache)) {
@@ -5374,10 +5409,7 @@ export default {
);
// convert decoded codec2 to wav audio
- const wavAudio = assertByteLengthAtMost(
- await Codec2Lib.rawToWav(decoded),
- MAX_CODEC2_WAV_BYTES
- );
+ const wavAudio = assertByteLengthAtMost(await Codec2Lib.rawToWav(decoded), MAX_CODEC2_WAV_BYTES);
// create blob from wav audio
const blob = new Blob([wavAudio], {
@@ -5401,6 +5433,7 @@ export default {
} else {
opusAudioBytes = new Uint8Array(audioBytes);
}
+ opusAudioBytes = assertByteLengthAtMost(opusAudioBytes, MAX_OPUS_ATTACHMENT_BYTES);
// create blob from opus audio
const blob = new Blob([opusAudioBytes], {
@@ -7043,10 +7076,7 @@ export default {
);
// convert decoded codec2 to wav audio and create a blob
- const wavAudio = assertByteLengthAtMost(
- await Codec2Lib.rawToWav(decoded),
- MAX_CODEC2_WAV_BYTES
- );
+ const wavAudio = assertByteLengthAtMost(await Codec2Lib.rawToWav(decoded), MAX_CODEC2_WAV_BYTES);
const wavBlob = new Blob([wavAudio], {
type: "audio/wav",
});
@@ -7227,16 +7257,26 @@ export default {
lines.push(`Method: ${lxmfMessage.method ?? "unknown"}`);
if (lxmfMessage.fields?.audio) {
- const audioSize =
- lxmfMessage.fields.audio.audio_size ??
- (lxmfMessage.fields.audio.audio_bytes ? atob(lxmfMessage.fields.audio.audio_bytes).length : 0);
+ let audioSize = lxmfMessage.fields.audio.audio_size ?? 0;
+ if (!audioSize && lxmfMessage.fields.audio.audio_bytes) {
+ try {
+ audioSize = atob(lxmfMessage.fields.audio.audio_bytes).length;
+ } catch {
+ audioSize = 0;
+ }
+ }
if (audioSize > 0) lines.push(`Audio Attachment: ${this.formatBytes(audioSize)}`);
}
if (lxmfMessage.fields?.image) {
- const imageSize =
- lxmfMessage.fields.image.image_size ??
- (lxmfMessage.fields.image.image_bytes ? atob(lxmfMessage.fields.image.image_bytes).length : 0);
+ let imageSize = lxmfMessage.fields.image.image_size ?? 0;
+ if (!imageSize && lxmfMessage.fields.image.image_bytes) {
+ try {
+ imageSize = atob(lxmfMessage.fields.image.image_bytes).length;
+ } catch {
+ imageSize = 0;
+ }
+ }
if (imageSize > 0) lines.push(`Image Attachment: ${this.formatBytes(imageSize)}`);
}

diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index f56e9d5f..1e9082a8 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -131,7 +131,7 @@
<button
v-if="!isPopoutMode && !destinationHash"
type="button"
- class="sm:hidden fixed bottom-5 right-4 z-65 flex h-14 w-14 items-center justify-center rounded-full bg-zinc-900 text-white shadow-lg ring-1 ring-white/10 transition active:scale-95 dark:bg-zinc-100 dark:text-zinc-900 dark:ring-zinc-800"
+ class="sm:hidden fixed z-65 flex h-14 w-14 items-center justify-center rounded-full bg-zinc-900 text-white shadow-lg ring-1 ring-white/10 transition active:scale-95 dark:bg-zinc-100 dark:text-zinc-900 dark:ring-zinc-800 right-[max(1rem,env(safe-area-inset-right,0px))] bottom-[max(1.25rem,env(safe-area-inset-bottom,0px))]"
:title="$t('app.compose')"
@click="openMobileCompose"
>
@@ -140,11 +140,11 @@
<div
v-if="isMobileComposeModalOpen"
- class="fixed inset-0 z-95 flex items-end justify-center sm:items-center p-0 sm:p-4 bg-black/50 backdrop-blur-xs sm:bg-black/50"
+ class="fixed inset-0 z-95 flex items-end justify-center sm:items-center p-0 sm:p-4 pt-[max(0.75rem,env(safe-area-inset-top))] pb-[max(0px,env(safe-area-inset-bottom))] bg-black/50 backdrop-blur-xs sm:bg-black/50"
@click.self="isMobileComposeModalOpen = false"
>
<div
- class="w-full sm:max-w-md bg-white dark:bg-zinc-900 rounded-t-2xl sm:rounded-2xl shadow-2xl overflow-hidden max-h-[90vh] flex flex-col"
+ class="w-full sm:max-w-md bg-white dark:bg-zinc-900 rounded-t-2xl sm:rounded-2xl shadow-2xl overflow-hidden max-h-[90dvh] flex flex-col"
@click.stop
>
<div

diff --git a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
index 6c15c8b1..40fec3c1 100644
--- a/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesSidebar.vue
@@ -188,7 +188,10 @@
</div>
</div>
</div>
- <div v-if="foldersExpanded" class="flex flex-col max-h-48 overflow-y-auto pb-1">
+ <div
+ v-if="foldersExpanded"
+ class="flex flex-col max-h-48 overflow-y-auto overscroll-y-contain pb-1"
+ >
<div
class="px-3 py-1.5 flex items-center gap-2 cursor-pointer transition-colors text-sm"
:class="[

diff --git a/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue b/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue
index 49f88721..90e3ab60 100644
--- a/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue
+++ b/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue
@@ -118,7 +118,8 @@
/>
<button
v-if="tabs.length > 1"
- class="ml-2 opacity-0 group-hover:opacity-100 hover:text-red-500 transition-opacity"
+ type="button"
+ class="ml-1 inline-flex min-h-[28px] min-w-[28px] items-center justify-center opacity-100 lg:opacity-0 lg:group-hover:opacity-100 hover:text-red-500 transition-opacity"
@click.stop="removeTab(index)"
>
<MaterialDesignIcon icon-name="close" class="size-3" />
@@ -224,7 +225,7 @@ export default {
},
methods: {
handleResize() {
- this.isMobileView = window.innerWidth < 1024;
+ this.isMobileView = window.innerWidth < 768;
if (!this.isMobileView) {
this.showEditor = true;
}

diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
index a2cce194..6a42d80b 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
@@ -507,8 +507,7 @@
<Teleport to="body">
<div
v-if="multilineHintVisible"
- class="multiline-hint pointer-events-none fixed bottom-3 right-3 px-2 py-1 rounded text-xs bg-amber-300 text-zinc-900 shadow"
- style="z-index: 9999"
+ class="multiline-hint pointer-events-none fixed z-200 bottom-[max(0.75rem,env(safe-area-inset-bottom))] right-[max(0.75rem,env(safe-area-inset-right))] px-2 py-1 rounded text-xs bg-amber-300 text-zinc-900 shadow"
>
{{ $t("nomadnet.multiline_hint") }}
</div>

diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index a95c1a1e..1143c730 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -5,7 +5,9 @@
v-if="config"
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-linear-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
- <div class="flex-1 overflow-y-auto overflow-x-hidden w-full min-w-0 px-3 sm:px-5 md:px-5 lg:px-8 py-4 sm:py-6">
+ <div
+ class="flex-1 overflow-y-auto overflow-x-hidden w-full min-w-0 px-3 sm:px-5 md:px-5 lg:px-8 py-4 sm:py-6 pb-[max(1.5rem,env(safe-area-inset-bottom))]"
+ >
<div class="space-y-0 w-full max-w-6xl xl:max-w-7xl 2xl:max-w-360 mx-auto min-w-0">
<div class="settings-section settings-section--hero">
<div class="flex flex-col lg:flex-row lg:items-center gap-4">

diff --git a/meshchatx/src/frontend/js/DownloadUtils.js b/meshchatx/src/frontend/js/DownloadUtils.js
index 5799bda2..12d9dc3f 100644
--- a/meshchatx/src/frontend/js/DownloadUtils.js
+++ b/meshchatx/src/frontend/js/DownloadUtils.js
@@ -7,6 +7,18 @@ function isAndroidSaveBridge() {
}
class DownloadUtils {
+ static sanitizeDownloadFilename(filename, defaultFilename = "download") {
+ let name = filename == null ? "" : String(filename);
+ // eslint-disable-next-line no-control-regex -- strip CR/LF/NUL from peer-provided names
+ name = name.replace(/[\r\n\x00]/g, "").trim();
+ // Drop path segments from naive Content-Disposition or peer-provided names.
+ name = name.split(/[/\\]/).pop() || "";
+ if (!name || name === "." || name === "..") {
+ return defaultFilename;
+ }
+ return name;
+ }
+
static parseFilenameFromContentDisposition(header, defaultFilename) {
if (!header || typeof header !== "string") {
return defaultFilename;
@@ -14,14 +26,14 @@ class DownloadUtils {
const star = header.match(/filename\*=UTF-8''([^;\s]+)/i);
if (star?.[1]) {
try {
- return decodeURIComponent(star[1]);
+ return DownloadUtils.sanitizeDownloadFilename(decodeURIComponent(star[1]), defaultFilename);
} catch {
// fall through
}
}
const plain = header.match(/filename="?([^";\n]+)"?/i);
if (plain?.[1]) {
- return plain[1].trim();
+ return DownloadUtils.sanitizeDownloadFilename(plain[1].trim(), defaultFilename);
}
return defaultFilename;
}
@@ -64,8 +76,9 @@ class DownloadUtils {
}
static downloadFromBase64(filename, fileBytesBase64) {
+ const safeName = DownloadUtils.sanitizeDownloadFilename(filename, "download");
if (isAndroidSaveBridge()) {
- window.MeshChatXAndroid.saveDownload(filename, fileBytesBase64);
+ window.MeshChatXAndroid.saveDownload(safeName, fileBytesBase64);
return;
}
const byteCharacters = atob(fileBytesBase64);
@@ -76,17 +89,18 @@ class DownloadUtils {
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray]);
const objectUrl = URL.createObjectURL(blob);
- DownloadUtils._triggerBrowserDownload(filename, objectUrl);
+ DownloadUtils._triggerBrowserDownload(safeName, objectUrl);
}
static async downloadFile(filename, blob) {
+ const safeName = DownloadUtils.sanitizeDownloadFilename(filename, "download");
if (isAndroidSaveBridge()) {
const b64 = await DownloadUtils._blobToBase64(blob);
- window.MeshChatXAndroid.saveDownload(filename, b64);
+ window.MeshChatXAndroid.saveDownload(safeName, b64);
return;
}
const objectUrl = URL.createObjectURL(blob);
- DownloadUtils._triggerBrowserDownload(filename, objectUrl);
+ DownloadUtils._triggerBrowserDownload(safeName, objectUrl);
}
}

diff --git a/meshchatx/src/frontend/js/MicronParser.js b/meshchatx/src/frontend/js/MicronParser.js
index 2ea5fbe5..86359869 100644
--- a/meshchatx/src/frontend/js/MicronParser.js
+++ b/meshchatx/src/frontend/js/MicronParser.js
@@ -77,9 +77,18 @@ export default class MicronParser extends BaseMicronParser {
static stripOverlayStyles(html) {
if (typeof html !== "string") return html;
const dangerousProps = ["zindex", "inset", "top", "left", "right", "bottom", "transform"];
- return html.replace(/(\s)style="([^"]*)"/g, (match, space, styleValue) => {
+ const scrubStyleValue = (styleValue) => {
// Strip CSS comments so position/**/:fixed cannot hide the colon.
- const cleaned = String(styleValue).replace(/\/\*[\s\S]*?\*\//g, "");
+ let cleaned = String(styleValue).replace(/\/\*[\s\S]*?\*\//g, "");
+ // Unescape simple CSS hex escapes (fixe\64 => fixed) used to hide overlays.
+ cleaned = cleaned.replace(/\\([0-9a-fA-F]{1,6})\s?/g, (_, hex) => {
+ try {
+ return String.fromCodePoint(parseInt(hex, 16));
+ } catch {
+ return "";
+ }
+ });
+ cleaned = cleaned.replace(/\\(.)/g, "$1");
const declarations = cleaned.split(";").filter(Boolean);
const safe = declarations.filter((decl) => {
const colon = decl.indexOf(":");
@@ -101,9 +110,17 @@ export default class MicronParser extends BaseMicronParser {
if (prop === "height" && /100v[hw]/.test(val)) return false;
return true;
});
- const out = safe.join("; ").trim();
- return out ? `${space}style="${out}"` : "";
+ return safe.join("; ").trim();
+ };
+ let out = html.replace(/(\s)style="([^"]*)"/gi, (match, space, styleValue) => {
+ const scrubbed = scrubStyleValue(styleValue);
+ return scrubbed ? `${space}style="${scrubbed}"` : "";
+ });
+ out = out.replace(/(\s)style='([^']*)'/gi, (match, space, styleValue) => {
+ const scrubbed = scrubStyleValue(styleValue);
+ return scrubbed ? `${space}style="${scrubbed}"` : "";
});
+ return out;
}
static sanitizeRenderedMicronHtml(html) {

diff --git a/meshchatx/src/frontend/js/NomadPageRenderer.js b/meshchatx/src/frontend/js/NomadPageRenderer.js
index 292066e1..9e7a5a40 100644
--- a/meshchatx/src/frontend/js/NomadPageRenderer.js
+++ b/meshchatx/src/frontend/js/NomadPageRenderer.js
@@ -72,6 +72,15 @@ export function stripOverlayFromCss(css) {
return "";
}
let s = String(css).replace(/\/\*[\s\S]*?\*\//g, "");
+ // Unescape CSS hex escapes so position:fixe\64 is treated as fixed.
+ s = s.replace(/\\([0-9a-fA-F]{1,6})\s?/g, (_, hex) => {
+ try {
+ return String.fromCodePoint(parseInt(hex, 16));
+ } catch {
+ return "";
+ }
+ });
+ s = s.replace(/\\(.)/g, "$1");
s = s.replace(/position\s*:\s*[^;{}]+/gi, (decl) => {
const lower = decl.toLowerCase();
if (/\bfixed\b/.test(lower) || /\bsticky\b/.test(lower)) {

diff --git a/meshchatx/src/frontend/js/codec2DecodeLimits.js b/meshchatx/src/frontend/js/codec2DecodeLimits.js
index 2e075119..0f54fac7 100644
--- a/meshchatx/src/frontend/js/codec2DecodeLimits.js
+++ b/meshchatx/src/frontend/js/codec2DecodeLimits.js
@@ -6,6 +6,8 @@ export const MAX_CODEC2_ENCODED_BYTES = 512 * 1024;
export const MAX_CODEC2_DECODED_RAW_BYTES = 8 * 1024 * 1024;
/** WAV header + PCM. Slightly above raw cap. */
export const MAX_CODEC2_WAV_BYTES = MAX_CODEC2_DECODED_RAW_BYTES + 44;
+/** Opus attachments are stored compressed; still bound blob URL creation. */
+export const MAX_OPUS_ATTACHMENT_BYTES = 2 * 1024 * 1024;
/**
* @param {ArrayBuffer | Uint8Array | null | undefined} data

diff --git a/meshchatx/src/frontend/style.css b/meshchatx/src/frontend/style.css
index fced0557..5514be92 100644
--- a/meshchatx/src/frontend/style.css
+++ b/meshchatx/src/frontend/style.css
@@ -83,8 +83,8 @@
}
*::-webkit-scrollbar {
- width: 10px;
- height: 10px;
+ width: 8px;
+ height: 8px;
}
*::-webkit-scrollbar-track {

diff --git a/tests/backend/test_security_exploratory_oracle.py b/tests/backend/test_security_exploratory_oracle.py
new file mode 100644
index 00000000..4a30b190
--- /dev/null
+++ b/tests/backend/test_security_exploratory_oracle.py
@@ -0,0 +1,199 @@
+# SPDX-License-Identifier: 0BSD
+"""Exploratory fuzzing and light oracles for hardened security surfaces.
+
+Oracles are intentional invariants (not full differential testing):
+ - WebAudioSource never raises on arbitrary PCM and drops oversized frames
+ - call timeout parse always lands in [1, 120]
+ - attachment-like field shapes never crash normalize helpers
+"""
+
+from __future__ import annotations
+
+import base64
+from unittest.mock import MagicMock
+
+import numpy as np
+import pytest
+from hypothesis import HealthCheck, given, settings
+from hypothesis import strategies as st
+
+from meshchatx.src.backend.call_timeout import (
+ MAX_CALL_TIMEOUT_SECONDS,
+ MIN_CALL_TIMEOUT_SECONDS,
+ clamp_call_timeout_seconds,
+)
+from meshchatx.src.backend.web_audio_bridge import WebAudioSource
+
+
+class _Sink:
+ def __init__(self):
+ self.frames = []
+
+ def can_receive(self, from_source=None):
+ return True
+
+ def handle_frame(self, frame, source=None):
+ self.frames.append(frame)
+
+
+# ---------------------------------------------------------------------------
+# Light oracles: call timeout
+# ---------------------------------------------------------------------------
+
+
+@settings(deadline=None, max_examples=80, suppress_health_check=[HealthCheck.too_slow])
+@given(
+ raw=st.one_of(
+ st.none(),
+ st.integers(min_value=-10_000, max_value=10_000),
+ st.text(max_size=40),
+ st.sampled_from(["", "15", "0", "-1", "999999", "1.5", "nan", "0x10", "timeout"]),
+ ),
+)
+def test_oracle_call_timeout_always_in_bounds(raw):
+ """Oracle: every parseable/unparseable input clamps into the safe window."""
+ out = clamp_call_timeout_seconds(raw)
+ assert MIN_CALL_TIMEOUT_SECONDS <= out <= MAX_CALL_TIMEOUT_SECONDS
+
+
+def test_oracle_call_timeout_known_cases():
+ assert clamp_call_timeout_seconds(None) == 15
+ assert clamp_call_timeout_seconds("15") == 15
+ assert clamp_call_timeout_seconds("1") == 1
+ assert clamp_call_timeout_seconds("120") == 120
+ assert clamp_call_timeout_seconds("0") == 1
+ assert clamp_call_timeout_seconds("-5") == 1
+ assert clamp_call_timeout_seconds("99999") == 120
+ assert clamp_call_timeout_seconds("nope") == 15
+
+
+# ---------------------------------------------------------------------------
+# Exploratory + oracle: web audio PCM
+# ---------------------------------------------------------------------------
+
+
+@settings(deadline=None, max_examples=60, suppress_health_check=[HealthCheck.too_slow])
+@given(pcm=st.binary(min_size=0, max_size=WebAudioSource.MAX_PCM_BYTES + 4096))
+def test_exploratory_web_audio_source_never_raises(pcm):
+ """Exploratory: arbitrary PCM must not crash push_pcm."""
+ sink = _Sink()
+ src = WebAudioSource(target_frame_ms=60, sink=sink)
+ try:
+ src.push_pcm(pcm)
+ except Exception as exc:
+ pytest.fail(f"push_pcm raised: {exc}")
+
+
+@settings(
+ deadline=None,
+ max_examples=30,
+ suppress_health_check=[HealthCheck.too_slow, HealthCheck.large_base_example],
+)
+@given(extra=st.integers(min_value=1, max_value=4096))
+def test_oracle_web_audio_drops_oversized_pcm(extra):
+ """Oracle: frames above MAX_PCM_BYTES never reach the sink."""
+ pcm = b"\x00" * (WebAudioSource.MAX_PCM_BYTES + extra)
+ sink = _Sink()
+ src = WebAudioSource(target_frame_ms=60, sink=sink)
+ src.push_pcm(pcm)
+ assert sink.frames == []
+
+
+def test_oracle_web_audio_drops_exact_oversize_boundary():
+ sink = _Sink()
+ src = WebAudioSource(target_frame_ms=60, sink=sink)
+ src.push_pcm(b"\x00" * (WebAudioSource.MAX_PCM_BYTES + 1))
+ assert sink.frames == []
+ src.push_pcm(np.zeros(160, dtype=np.int16).tobytes())
+ assert len(sink.frames) == 1
+
+
+# ---------------------------------------------------------------------------
+# Light oracles: attachment-like field shapes (mirror serve guards)
+# ---------------------------------------------------------------------------
+
+
+def _safe_image_type(raw) -> str:
+ allowed = {"png", "jpeg", "jpg", "gif", "webp", "bmp"}
+ image_type = raw if isinstance(raw, str) else "png"
+ image_type = image_type.lower().replace("image/", "").strip() or "png"
+ if image_type not in allowed:
+ image_type = "png"
+ return image_type
+
+
+def _try_b64(raw):
+ if not isinstance(raw, str) or not raw:
+ return None
+ try:
+ return base64.b64decode(raw)
+ except Exception:
+ return None
+
+
+@settings(deadline=None, max_examples=80, suppress_health_check=[HealthCheck.too_slow])
+@given(
+ image_type=st.one_of(
+ st.none(),
+ st.integers(),
+ st.booleans(),
+ st.lists(st.text(max_size=8), max_size=3),
+ st.text(max_size=40),
+ st.sampled_from(["png", "PNG", "image/jpeg", "svg", "svg+xml", "webm", "", " "]),
+ ),
+)
+def test_oracle_image_type_always_allowlisted(image_type):
+ """Oracle: served image types collapse to the raster allowlist."""
+ out = _safe_image_type(image_type)
+ assert out in {"png", "jpeg", "jpg", "gif", "webp", "bmp"}
+
+
+@settings(deadline=None, max_examples=60, suppress_health_check=[HealthCheck.too_slow])
+@given(
+ raw=st.one_of(
+ st.none(),
+ st.binary(max_size=64).map(lambda b: base64.b64encode(b).decode("ascii")),
+ st.text(max_size=80),
+ st.integers(),
+ st.sampled_from(["", "====", "not-base64!!!", "A", "QQ=="]),
+ ),
+)
+def test_exploratory_b64_decode_never_raises(raw):
+ """Exploratory: attachment base64 decode path must not throw."""
+ try:
+ _try_b64(raw)
+ except Exception as exc:
+ pytest.fail(f"b64 helper raised: {exc}")
+
+
+def test_oracle_known_bad_attachment_shapes_rejected():
+ assert _try_b64(None) is None
+ assert _try_b64(123) is None
+ assert _try_b64("") is None
+ assert _safe_image_type(None) == "png"
+ assert _safe_image_type(99) == "png"
+ assert _safe_image_type("svg") == "png"
+ assert _safe_image_type("image/webp") == "webp"
+
+
+# ---------------------------------------------------------------------------
+# Exploratory: bridge still never crashes with mocked sink under fuzz PCM
+# ---------------------------------------------------------------------------
+
+
+@settings(deadline=None, max_examples=40, suppress_health_check=[HealthCheck.too_slow])
+@given(pcm=st.binary(min_size=0, max_size=WebAudioSource.MAX_PCM_BYTES + 2048))
+def test_exploratory_bridge_push_with_real_source(pcm):
+ from meshchatx.src.backend.web_audio_bridge import WebAudioBridge
+
+ tele_mgr = MagicMock()
+ tele_mgr.is_voicemail_session_active = False
+ bridge = WebAudioBridge(tele_mgr, MagicMock())
+ sink = _Sink()
+ bridge.tx_source = WebAudioSource(target_frame_ms=60, sink=sink)
+ try:
+ bridge.push_client_frame(pcm)
+ except Exception as exc:
+ pytest.fail(f"push_client_frame raised: {exc}")
+ if len(pcm) > WebAudioSource.MAX_PCM_BYTES:
+ assert sink.frames == []

diff --git a/tests/frontend/NomadPageRenderer.security.test.js b/tests/frontend/NomadPageRenderer.security.test.js
index e295b3fb..1dda91d7 100644
--- a/tests/frontend/NomadPageRenderer.security.test.js
+++ b/tests/frontend/NomadPageRenderer.security.test.js
@@ -152,7 +152,7 @@ describe("NomadPageRenderer HTML document sanitization", () => {
it("strips fixed overlays from style blocks", () => {
const html = renderNomadHtmlPage(
- "<body><style>.x{position:fixed;inset:0;z-index:99999}</style><div class=\"x\">x</div></body>"
+ '<body><style>.x{position:fixed;inset:0;z-index:99999}</style><div class="x">x</div></body>'
);
expect(html.toLowerCase()).not.toMatch(/position\s*:\s*fixed/);
});

diff --git a/tests/frontend/Toast.test.js b/tests/frontend/Toast.test.js
index 5db03e04..6aed0fcb 100644
--- a/tests/frontend/Toast.test.js
+++ b/tests/frontend/Toast.test.js
@@ -120,8 +120,16 @@ describe("Toast.vue", () => {
const container = wrapper.find("[class*='fixed']");
expect(container.exists()).toBe(true);
const cls = container.classes().join(" ");
- expect(cls).toContain("max-sm:bottom-");
- expect(cls).not.toContain("max-sm:bottom-[calc(5.75rem");
+ expect(cls).toContain("max-sm:bottom-[calc(5.5rem+env(safe-area-inset-bottom,0px))]");
+ expect(cls).not.toContain("max-sm:bottom-[calc(1rem+env(safe-area-inset-bottom,0px))]");
+ });
+
+ it("close control has a 44px touch target", async () => {
+ GlobalEmitter.emit("toast", { message: "Hi", duration: 0 });
+ await wrapper.vm.$nextTick();
+ const closeButton = wrapper.find("button");
+ expect(closeButton.classes().join(" ")).toContain("min-h-[44px]");
+ expect(closeButton.classes().join(" ")).toContain("min-w-[44px]");
});
it("dismisses toast on horizontal swipe past threshold", async () => {

diff --git a/tests/frontend/__snapshots__/uiSnapshots.test.js.snap b/tests/frontend/__snapshots__/uiSnapshots.test.js.snap
index e592e006..820c5ae3 100644
--- a/tests/frontend/__snapshots__/uiSnapshots.test.js.snap
+++ b/tests/frontend/__snapshots__/uiSnapshots.test.js.snap
@@ -10,10 +10,10 @@ exports[`UI snapshot regression > IconButton.vue > icon button 1`] = `"<button t
exports[`UI snapshot regression > SidebarLink.vue > sidebar link 1`] = `"<a class="router-link-stub" href="#" custom=""><a href="#" type="button" class="hover:bg-gray-100 dark:hover:bg-zinc-700 overflow-hidden w-full text-gray-800 dark:text-zinc-200 group flex gap-x-3 rounded-r-full p-2 mr-2 text-sm leading-6 font-semibold focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:focus-visible:outline-zinc-500"><span class="my-auto shrink-0"><span class="icon-slot">M</span></span><span class="my-auto flex w-full truncate transition-all duration-300">Messages</span></a></a>"`;
-exports[`UI snapshot regression > Toast.vue > empty state 1`] = `"<div="" class="fixed max-sm:bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"><div="" class="snapshot-transition-group" name="toast"></div></div>"`;
+exports[`UI snapshot regression > Toast.vue > empty state 1`] = `"<div="" class="fixed max-sm:bottom-[calc(5.5rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"><div="" class="snapshot-transition-group" name="toast"></div></div>"`;
-exports[`UI snapshot regression > Toast.vue > error toast 1`] = `"<div="" class="fixed max-sm:bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"><div="" class="snapshot-transition-group" name="toast"></div></div>"`;
+exports[`UI snapshot regression > Toast.vue > error toast 1`] = `"<div="" class="fixed max-sm:bottom-[calc(5.5rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"><div="" class="snapshot-transition-group" name="toast"></div></div>"`;
-exports[`UI snapshot regression > Toast.vue > loading toast 1`] = `"<div="" class="fixed max-sm:bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"><div="" class="snapshot-transition-group" name="toast"></div></div>"`;
+exports[`UI snapshot regression > Toast.vue > loading toast 1`] = `"<div="" class="fixed max-sm:bottom-[calc(5.5rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"><div="" class="snapshot-transition-group" name="toast"></div></div>"`;
-exports[`UI snapshot regression > Toast.vue > success toast 1`] = `"<div="" class="fixed max-sm:bottom-[calc(1rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"><div="" class="snapshot-transition-group" name="toast"></div></div>"`;
+exports[`UI snapshot regression > Toast.vue > success toast 1`] = `"<div="" class="fixed max-sm:bottom-[calc(5.5rem+env(safe-area-inset-bottom,0px))] bottom-4 left-1/2 -translate-x-1/2 sm:left-auto sm:right-4 sm:translate-x-0 z-100 flex flex-col gap-2 pointer-events-none w-[calc(100%-2rem)] max-w-sm sm:w-auto sm:max-w-md"><div="" class="snapshot-transition-group" name="toast"></div></div>"`;

diff --git a/tests/frontend/codec2DecodeLimits.test.js b/tests/frontend/codec2DecodeLimits.test.js
index c740d354..90b5a094 100644
--- a/tests/frontend/codec2DecodeLimits.test.js
+++ b/tests/frontend/codec2DecodeLimits.test.js
@@ -14,9 +14,9 @@ describe("codec2DecodeLimits", () => {
});
it("rejects oversized encoded and decoded buffers", () => {
- expect(() => assertByteLengthAtMost(new Uint8Array(MAX_CODEC2_ENCODED_BYTES + 1), MAX_CODEC2_ENCODED_BYTES)).toThrow(
- /exceeds size limit/
- );
+ expect(() =>
+ assertByteLengthAtMost(new Uint8Array(MAX_CODEC2_ENCODED_BYTES + 1), MAX_CODEC2_ENCODED_BYTES)
+ ).toThrow(/exceeds size limit/);
expect(() =>
assertByteLengthAtMost(new Uint8Array(MAX_CODEC2_DECODED_RAW_BYTES + 1), MAX_CODEC2_DECODED_RAW_BYTES)
).toThrow(/exceeds size limit/);

diff --git a/tests/frontend/hotPathBugRegressions.test.js b/tests/frontend/hotPathBugRegressions.test.js
new file mode 100644
index 00000000..f6f24fd3
--- /dev/null
+++ b/tests/frontend/hotPathBugRegressions.test.js
@@ -0,0 +1,46 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Regression tests for bugs found by exploratory probing of hot paths.
+ */
+
+import { describe, expect, it } from "vitest";
+import MicronParser from "@/js/MicronParser.js";
+import { renderNomadHtmlPage, stripOverlayFromCss } from "@/js/NomadPageRenderer.js";
+import DownloadUtils from "@/js/DownloadUtils.js";
+
+describe("hot-path bug regressions", () => {
+ it("strips single-quoted position:fixed overlays", () => {
+ const out = MicronParser.stripOverlayStyles(`<div style='position:fixed; color:red'>x</div>`);
+ expect(out.toLowerCase()).not.toMatch(/position\s*:\s*fixed/);
+ expect(out).toContain("color:red");
+ });
+
+ it("strips CSS hex-escaped fixed (fixe\\64) in style blocks", () => {
+ const out = stripOverlayFromCss("x{position:fixe\\64;inset:0}");
+ expect(out.toLowerCase()).not.toMatch(/fixe\\64/);
+ expect(out.toLowerCase()).not.toMatch(/position\s*:\s*fixed/);
+ expect(out.toLowerCase()).toContain("position:static");
+ });
+
+ it("Nomad HTML render neutralises CSS-escaped fixed overlays", () => {
+ const html = renderNomadHtmlPage(
+ "<body><style>.x{position:fixe\\64;inset:0;z-index:9}</style><div class=x>y</div></body>"
+ );
+ expect(html.toLowerCase()).not.toMatch(/fixe\\64/);
+ expect(html.toLowerCase()).not.toMatch(/position\s*:\s*fixed/);
+ });
+
+ it("download filenames drop path traversal segments", () => {
+ expect(
+ DownloadUtils.parseFilenameFromContentDisposition('attachment; filename="../../evil.html"', "safe.bin")
+ ).toBe("evil.html");
+ expect(DownloadUtils.sanitizeDownloadFilename("../a/b\\c.txt", "x.bin")).toBe("c.txt");
+ expect(DownloadUtils.sanitizeDownloadFilename("..", "x.bin")).toBe("x.bin");
+ });
+
+ it("download filenames strip CR/LF/NUL", () => {
+ expect(DownloadUtils.sanitizeDownloadFilename("a\r\nb.html", "x.bin")).toBe("ab.html");
+ expect(DownloadUtils.sanitizeDownloadFilename("a\x00b.bin", "x.bin")).toBe("ab.bin");
+ });
+});

diff --git a/tests/frontend/layoutUxOracle.test.js b/tests/frontend/layoutUxOracle.test.js
new file mode 100644
index 00000000..e0b67335
--- /dev/null
+++ b/tests/frontend/layoutUxOracle.test.js
@@ -0,0 +1,130 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { mount } from "@vue/test-utils";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import Toast from "@/components/Toast.vue";
+import CallOverlay from "@/components/call/CallOverlay.vue";
+import GlobalEmitter from "@/js/GlobalEmitter";
+
+const src = (rel) => readFileSync(resolve(__dirname, "../../meshchatx/src/frontend", rel), "utf8");
+
+describe("layout UX oracle-light", () => {
+ it("App shell drawer sits in middle pane without fake pt-16 header gap", () => {
+ const app = src("components/App.vue");
+ expect(app).toContain('class="relative flex flex-1 w-full overflow-hidden');
+ expect(app).toContain("absolute inset-y-0 left-0 z-70");
+ expect(app).toContain("absolute inset-0 z-65");
+ expect(app).not.toContain("pt-16 sm:pt-0");
+ expect(app).toContain("pt-[env(safe-area-inset-top,0px)]");
+ });
+
+ it("ConversationViewer message list uses overflow-y-auto and composer safe-area", () => {
+ const cv = src("components/messages/ConversationViewer.vue");
+ expect(cv).toContain("overflow-y-auto bg-white dark:bg-zinc-950");
+ expect(cv).not.toMatch(/id="messages"[\s\S]{0,200}overflow-y-scroll/);
+ expect(cv).toContain("composerChromeStyle");
+ expect(cv).toContain("updateKeyboardInset");
+ expect(cv).toContain("env(safe-area-inset-bottom");
+ expect(cv).toContain("flex-col gap-3 text-sm sm:flex-row sm:items-center");
+ });
+
+ it("MessagesPage mobile compose FAB and sheet clear safe-area and use dvh", () => {
+ const page = src("components/messages/MessagesPage.vue");
+ expect(page).toContain("bottom-[max(1.25rem,env(safe-area-inset-bottom,0px))]");
+ expect(page).toContain("max-h-[90dvh]");
+ expect(page).not.toContain("max-h-[90vh]");
+ });
+
+ it("MicronEditor shows tab close on touch and splits at tablet width", () => {
+ const page = src("components/micron-editor/MicronEditorPage.vue");
+ expect(page).toContain("opacity-100 lg:opacity-0 lg:group-hover:opacity-100");
+ expect(page).toContain("window.innerWidth < 768");
+ });
+
+ it("Settings page scroll includes bottom safe-area", () => {
+ const page = src("components/settings/SettingsPage.vue");
+ expect(page).toContain("pb-[max(1.5rem,env(safe-area-inset-bottom))]");
+ });
+
+ describe("Toast + CallOverlay stacking", () => {
+ let toastWrapper;
+ let callWrapper;
+
+ beforeEach(() => {
+ toastWrapper = mount(Toast, {
+ global: {
+ mocks: { $t: (msg) => msg },
+ stubs: {
+ TransitionGroup: { template: "<div><slot /></div>" },
+ MaterialDesignIcon: { template: '<div class="mdi-stub"></div>', props: ["iconName"] },
+ },
+ },
+ });
+ callWrapper = mount(CallOverlay, {
+ props: {
+ activeCall: {
+ remote_identity_hash: "abcdef0123456789abcdef0123456789",
+ remote_identity_name: "Peer",
+ status: 6,
+ is_incoming: false,
+ is_voicemail: false,
+ call_start_time: Date.now() / 1000,
+ tx_bytes: 0,
+ rx_bytes: 0,
+ },
+ },
+ global: {
+ mocks: {
+ $t: (msg) => msg,
+ $router: { push: vi.fn() },
+ },
+ stubs: {
+ MaterialDesignIcon: true,
+ LxmfUserIcon: true,
+ AudioWaveformPlayer: true,
+ Transition: { template: "<div><slot /></div>" },
+ },
+ },
+ });
+ });
+
+ afterEach(() => {
+ toastWrapper?.unmount();
+ callWrapper?.unmount();
+ });
+
+ it("mobile toast bottom clears compose FAB band", () => {
+ const cls = toastWrapper.find("[class*='fixed']").classes().join(" ");
+ expect(cls).toContain("max-sm:bottom-[calc(5.5rem+env(safe-area-inset-bottom,0px))]");
+ });
+
+ it("call overlay uses safe-area bottom and responsive width", () => {
+ const root = callWrapper.find("[class*='fixed']");
+ expect(root.exists()).toBe(true);
+ const cls = root.classes().join(" ");
+ expect(cls).toContain("w-[min(20rem,calc(100%-1.5rem))]");
+ expect(cls).toContain("bottom-[max(1rem,env(safe-area-inset-bottom,0px))]");
+ expect(cls).toContain("max-sm:bottom-[calc(5.5rem+env(safe-area-inset-bottom,0px))]");
+ });
+ });
+
+ it("toast dismiss key still works after touch-target change", async () => {
+ const wrapper = mount(Toast, {
+ global: {
+ mocks: { $t: (msg) => msg },
+ stubs: {
+ TransitionGroup: { template: "<div><slot /></div>" },
+ MaterialDesignIcon: { template: '<div class="mdi-stub"></div>', props: ["iconName"] },
+ },
+ },
+ });
+ GlobalEmitter.emit("toast", { message: "X", duration: 0 });
+ await wrapper.vm.$nextTick();
+ await wrapper.find("button").trigger("click");
+ await wrapper.vm.$nextTick();
+ expect(wrapper.text()).not.toContain("X");
+ wrapper.unmount();
+ });
+});

diff --git a/tests/frontend/securityExploratoryOracle.test.js b/tests/frontend/securityExploratoryOracle.test.js
new file mode 100644
index 00000000..8ad20d69
--- /dev/null
+++ b/tests/frontend/securityExploratoryOracle.test.js
@@ -0,0 +1,248 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Exploratory fuzzing and light oracles for Micron/Nomad/codec2/memory hardening.
+ *
+ * Light oracles = invariant checks on every output (no full differential engine):
+ * - no script / javascript / iframe / fixed overlays after sanitize
+ * - codec2 size gate rejects oversize and accepts undersize
+ * - memory warning classifier ignores non-memory kinds
+ */
+
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import MicronParser from "@/js/MicronParser.js";
+import {
+ renderNomadHtmlPage,
+ renderNomadMarkdown,
+ sanitizeNomadHtmlFragment,
+ stripExternalFromCss,
+ stripOverlayFromCss,
+} from "@/js/NomadPageRenderer.js";
+import {
+ MAX_CODEC2_DECODED_RAW_BYTES,
+ MAX_CODEC2_ENCODED_BYTES,
+ assertByteLengthAtMost,
+} from "@/js/codec2DecodeLimits.js";
+import {
+ evaluateClientHeapSample,
+ handleHealthWarningPayload,
+ isMemoryHealthWarningPayload,
+ resetMemoryWarningStateForTests,
+} from "@/js/healthMemoryWarning.js";
+
+const DANGEROUS_TAGS = [/<script[\s>]/i, /<iframe[\s>]/i, /<object[\s>]/i, /<embed[\s>]/i];
+
+const OVERLAY_STYLE_ATTACKS = [
+ 'style="position:fixed !important; inset:0; z-index:99999"',
+ 'style="position/**/:fixed; top:0; left:0"',
+ 'style="position: sticky !important; transform: translateY(0)"',
+ 'style="POSITION:FiXeD; width:100vw; height:100vh"',
+];
+
+function assertSafeHtmlOracle(html) {
+ expect(typeof html).toBe("string");
+ const lower = html.toLowerCase();
+ for (const re of DANGEROUS_TAGS) {
+ expect(html).not.toMatch(re);
+ }
+ // Executable URL contexts only (plain text mentioning javascript: is fine).
+ expect(lower).not.toMatch(/\bhref\s*=\s*["']?\s*javascript\s*:/);
+ expect(lower).not.toMatch(/\bsrc\s*=\s*["']?\s*javascript\s*:/);
+ expect(lower).not.toMatch(/\bhref\s*=\s*["']?\s*vbscript\s*:/);
+ expect(lower).not.toMatch(/position\s*:\s*fixed/);
+ expect(lower).not.toMatch(/position\s*:\s*sticky/);
+}
+
+function randomStyleValue(len) {
+ const tokens = [
+ "position:fixed",
+ "position:fixed !important",
+ "position/**/:fixed",
+ "position:sticky",
+ "inset:0",
+ "z-index:99999",
+ "top:0",
+ "left:0",
+ "transform:scale(1)",
+ "width:100vw",
+ "height:100vh",
+ "color:red",
+ "font-size:12px",
+ "background:#fff",
+ "/*comment*/",
+ "",
+ ];
+ const parts = [];
+ for (let i = 0; i < len; i++) {
+ parts.push(tokens[Math.floor(Math.random() * tokens.length)]);
+ }
+ return parts.join("; ");
+}
+
+function randomHtmlSnippet() {
+ const snippets = [
+ "<script>alert(1)</script>",
+ "<img src=x onerror=alert(1)>",
+ "<a href=javascript:alert(1)>x</a>",
+ "<svg onload=alert(1)>",
+ "<iframe src=https://evil></iframe>",
+ "<style>@import url(http://x); body{position:fixed}</style>",
+ '<div style="position:fixed !important">phish</div>',
+ "<p onclick=alert(1)>x</p>",
+ "normal text " + String.fromCharCode(0x1f4a3),
+ "<a href='vbscript:msgbox(1)'>x</a>",
+ ];
+ return snippets[Math.floor(Math.random() * snippets.length)];
+}
+
+describe("exploratory + oracle-light: Micron overlay strip", () => {
+ it("oracle: known overlay attacks lose fixed/sticky", () => {
+ for (const attack of OVERLAY_STYLE_ATTACKS) {
+ const html = MicronParser.stripOverlayStyles(`<div ${attack}>x</div>`);
+ assertSafeHtmlOracle(html);
+ expect(html).toContain("x");
+ }
+ });
+
+ it("exploratory: random style attributes never throw and never keep fixed/sticky", () => {
+ for (let i = 0; i < 300; i++) {
+ const style = randomStyleValue(1 + (i % 8));
+ let out;
+ expect(() => {
+ out = MicronParser.stripOverlayStyles(`<span style="${style}">n</span>`);
+ }).not.toThrow();
+ assertSafeHtmlOracle(out);
+ }
+ });
+
+ it("exploratory: sanitizeRenderedMicronHtml on mixed XSS payloads", () => {
+ for (let i = 0; i < 200; i++) {
+ const payload =
+ randomHtmlSnippet() +
+ (i % 3 === 0 ? `<div ${OVERLAY_STYLE_ATTACKS[i % OVERLAY_STYLE_ATTACKS.length]}>z</div>` : "");
+ let out;
+ expect(() => {
+ out = MicronParser.sanitizeRenderedMicronHtml(payload);
+ }).not.toThrow();
+ assertSafeHtmlOracle(out);
+ }
+ });
+});
+
+describe("exploratory + oracle-light: Nomad HTML/MD/CSS", () => {
+ it("oracle: style-block overlays are neutralized", () => {
+ const css = stripOverlayFromCss(".x{position:fixed !important;inset:0;z-index:9}");
+ expect(css.toLowerCase()).not.toMatch(/position\s*:\s*fixed/);
+ expect(stripExternalFromCss("@import url(http://x); .a{position:sticky}")).not.toMatch(/@import/i);
+ });
+
+ it("exploratory: renderNomadHtmlPage random payloads stay within oracle", () => {
+ for (let i = 0; i < 250; i++) {
+ let s = randomHtmlSnippet();
+ const extra = Math.floor(Math.random() * 400);
+ for (let j = 0; j < extra; j++) {
+ s += String.fromCharCode(Math.floor(Math.random() * 128));
+ }
+ let out;
+ expect(() => {
+ out = renderNomadHtmlPage(`<body>${s}</body>`);
+ }).not.toThrow();
+ assertSafeHtmlOracle(out);
+ }
+ });
+
+ it("exploratory: markdown + fragment path respect oracle", () => {
+ for (let i = 0; i < 120; i++) {
+ const md = `${"#".repeat(1 + (i % 3))} title ${i}\n\n[x](javascript:alert(1))\n\n<div style="position:fixed">y</div>`;
+ let html;
+ let frag;
+ expect(() => {
+ html = renderNomadMarkdown(md);
+ frag = sanitizeNomadHtmlFragment(`<div>${randomHtmlSnippet()}</div>`);
+ }).not.toThrow();
+ assertSafeHtmlOracle(html);
+ assertSafeHtmlOracle(frag);
+ }
+ });
+});
+
+describe("exploratory + oracle-light: codec2 size gate", () => {
+ it("oracle: under-cap buffers pass, over-cap buffers throw", () => {
+ expect(assertByteLengthAtMost(new Uint8Array(0), MAX_CODEC2_ENCODED_BYTES).byteLength).toBe(0);
+ expect(assertByteLengthAtMost(new Uint8Array(16), MAX_CODEC2_ENCODED_BYTES).byteLength).toBe(16);
+ expect(() =>
+ assertByteLengthAtMost(new Uint8Array(MAX_CODEC2_ENCODED_BYTES + 1), MAX_CODEC2_ENCODED_BYTES)
+ ).toThrow(/exceeds size limit/);
+ expect(() =>
+ assertByteLengthAtMost(new Uint8Array(MAX_CODEC2_DECODED_RAW_BYTES + 1), MAX_CODEC2_DECODED_RAW_BYTES)
+ ).toThrow(/exceeds size limit/);
+ });
+
+ it("exploratory: random sizes near boundaries behave as oracle predicts", () => {
+ const cases = [
+ 0,
+ 1,
+ MAX_CODEC2_ENCODED_BYTES - 1,
+ MAX_CODEC2_ENCODED_BYTES,
+ MAX_CODEC2_ENCODED_BYTES + 1,
+ Math.floor(Math.random() * MAX_CODEC2_ENCODED_BYTES),
+ ];
+ for (const n of cases) {
+ const buf = new Uint8Array(Math.max(0, n));
+ if (n <= MAX_CODEC2_ENCODED_BYTES) {
+ expect(assertByteLengthAtMost(buf, MAX_CODEC2_ENCODED_BYTES).byteLength).toBe(n);
+ } else {
+ expect(() => assertByteLengthAtMost(buf, MAX_CODEC2_ENCODED_BYTES)).toThrow(/exceeds size limit/);
+ }
+ }
+ });
+});
+
+describe("exploratory + oracle-light: memory warning classifier", () => {
+ beforeEach(() => {
+ resetMemoryWarningStateForTests();
+ });
+
+ it("oracle: only memory_low shows toast; other kinds ignored", () => {
+ const toast = { warning: vi.fn() };
+ const ignored = [
+ null,
+ undefined,
+ "memory_low",
+ { kind: "entropy_climbing" },
+ { kind: "error_rate_high" },
+ { type: "health_warning", data: { kind: "entropy_climbing" } },
+ { data: { kind: "something_else" } },
+ ];
+ for (const payload of ignored) {
+ expect(isMemoryHealthWarningPayload(payload)).toBe(false);
+ expect(handleHealthWarningPayload(payload, toast)).toBe("ignored");
+ }
+ expect(toast.warning).not.toHaveBeenCalled();
+ expect(handleHealthWarningPayload({ data: { kind: "memory_low" } }, toast)).toBe("shown");
+ expect(toast.warning).toHaveBeenCalledTimes(1);
+ });
+
+ it("exploratory: random heap samples never throw; single spike is not a warn", () => {
+ for (let i = 0; i < 200; i++) {
+ const limit = 1000 + Math.floor(Math.random() * 9000);
+ const used = Math.floor(Math.random() * limit * 1.2);
+ let result;
+ expect(() => {
+ result = evaluateClientHeapSample({
+ jsHeapSizeLimit: limit,
+ usedJSHeapSize: used,
+ });
+ }).not.toThrow();
+ expect(typeof result.shouldWarn).toBe("boolean");
+ expect(typeof result.reason).toBe("string");
+ }
+ resetMemoryWarningStateForTests();
+ const oneSpike = evaluateClientHeapSample({
+ jsHeapSizeLimit: 1000,
+ usedJSHeapSize: 900,
+ });
+ expect(oneSpike.shouldWarn).toBe(false);
+ expect(oneSpike.reason).toBe("need_consecutive");
+ });
+});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────